Skip to content

feat(routes): pass request identity, lineage, and read-only state/notices to context providers (#459) - #556

Closed
ScriptedAlchemy wants to merge 4 commits into
mainfrom
feat/459-provider-context
Closed

feat(routes): pass request identity, lineage, and read-only state/notices to context providers (#459)#556
ScriptedAlchemy wants to merge 4 commits into
mainfrom
feat/459-provider-context

Conversation

@ScriptedAlchemy

Copy link
Copy Markdown
Owner

Fixes #459

Why

A conventional src/providers/<name> factory received only { invocation, plugin, signal }, and every generated surface executed the provider loop before runAgentRequest opened the request — so a provider could not see the request's identity or lineage, and had no handle to the state and notices the request was about to mount. The worktree-proximity agent-topology provider had to stay an honest stub (#544), and any provider that wanted shared state had to open a store of its own.

Design

Providers run as the request's own resolver. runAgentRequest (@agent-bundle/runtime) now accepts providers either as the resolved record or as an AgentProviderResolver (request: AgentProviderRequest) => values | Promise<values>. The runtime runs the resolver after the identity axes are snapshotted and frozen and after the notice lease opens (so notices.inbox() is real), before the operation, and — via AsyncLocalStorage.exit — outside any request context, so agent()/useAgent() inside a factory throw outside-invocation even when an in-process host scope wraps the render (as the test harness does). The frozen providers map is mounted as before; a rejected resolver fails the request closed exactly like a rejected operation.

This was chosen over "open the request with a lazily-filled frozen record" because: (a) the request's providers is documented and typed as a frozen, fully resolved record — a lazily-filled one would either break the freeze or reintroduce the unchecked-undefined window the Register typegen exists to close; (b) a resolver keeps the generated loop and the harness's executeProviders one simple function that awaits real handles — a provider that reads inbox() eagerly can't deadlock on a lease that hasn't opened; (c) read-only narrowing is done by construction (providerRequest builds { inbox } / { lifetime, read } wrappers), not by type alone, so dispatch/publish/acknowledge are genuinely absent at run time.

AgentProviderRequest's handles are Pick<AgentStateHandle, 'lifetime' | 'read'> and Pick<AgentNoticesHandle, 'inbox'>.

agent-bundle:

  • AgentProviderContext gains host, session, workspace, lineage (each AgentProviderObserved<…>, tree included), state?: AgentProviderStateHandle, notices?: AgentProviderNoticesHandle, beside the existing invocation, plugin, signal. Spelled structurally in routes/public.ts (no runtime import for config-only consumers), matching the runtime's shapes field for field. New exports: AgentProviderObserved, AgentProviderLineage, AgentProviderLineageTree, AgentProviderLineagePeer, AgentProviderLineageSubagent, AgentProviderLineageResolution, AgentProviderStateHandle, AgentProviderStateSnapshot, AgentProviderNotice, AgentProviderNoticesHandle.
  • build/entry-shell.ts: providerExecutionSource + providerValuesExpression are replaced by providersFieldSource, which emits providers: async (request) => { …loop… } as the field of the runAgentRequest init on all three generated scopes (shared Flight worker behind MCP tools + event routes, rendered CLI/script worker, plain routed CLI). Each factory gets { ...request, invocation }. Projects without providers still emit providers: { processLifetime }.
  • routes/provider-execution.ts: executeProviders takes request: ProviderRequestView and spreads it onto the factory context; entry-shell.test.ts continues to pin the emitted loop and this helper together.
  • test/providers.ts: mountProviders returns the explicit map (verbatim — the route-unit fixture seam is unchanged), the bare process identity for a direct module render, or a resolver the harness's own runAgentRequest runs. cli.ts, mcp.ts, render.ts updated accordingly. Typegen is untouched (Register-declared keys apply to the resolver's return type too).

worktree-proximity example: agent-topology returns { agents, intent } from agentTreeOf(context.lineage) and context.state.read() (each half with its own availability); status.tsx reads providers.agentTopology and performs no second read. README updated where it said providers received no lineage. agentTree() (the route-side read) is deleted; agentTreeOf accepts the provider's structural lineage too.

Tests

  • packages/rsc-runtime/tests/agent-request.test.ts: resolver receives exactly { host, lineage, notices, plugin, session, signal, state, workspace }, runs after the lease opens and outside any enclosing request context (useAgent()outside-invocation), narrowed handles carry only inbox / lifetime+read, a rejection fails the request.
  • packages/agent-bundle/tests/entry-shell.test.ts: all three generated surfaces emit providers: async (request) => { with { ...request, invocation }; the loop sits inside the runAgentRequest init (after it opens, before the route); executeProviders spreads the view; Flight-worker determinism hash re-pinned.
  • packages/agent-bundle/tests/cli-routes-build.test.ts (built artifact, cross-process): the fixture gains a process-lifetime src/state.ts, and its provider reports the request view on every generated surface — plain CLI, rendered CLI, projected MCP command, rendered script — asserting host/lineage = unsupported-surface (the same typed reason the route reads), state = { keys: ['lifetime','read'], lifetime: 'process', revision: 0 }, notices = ['inbox'], plugin available, and useAgent()outside-invocation.
  • packages/agent-bundle/tests/projection/providers.test.ts + new fixtures/route-harness/src/providers/request-view.ts: harness surfaces (CLI dispatch, in-memory MCP, route-unit render, script) all hand the view; a registry-backed in-memory MCP call shows the lineage the call resolved to with its live tree.siblings, a real notices.inbox(), and read-only state; a route-unit render with injected host/lineage/session/workspace shows them reflected in the provider.
  • provider-typegen.test.ts: a resolver providers: async (request) => … is typed against the declared keys (missing key is a compile error) and reads lineage.value.tree, state.read(), notices.inbox() through AgentProviderContext.
  • test-harness-manifest.test.ts, script-dispatch.test.ts: manifest/loader tables include the new fixture provider.
  • examples/worktree-proximity/tests/route-unit/routes.test.ts: the real agent-topology factory over the request view (tree + intent read), and the honest unavailable shape without a state handle; the status tests run it with the lineage they inject.
  • worktree-proximity-journeys.test.ts (cross-process): passes unchanged.

Docs

  • docs/entry-conventions.md: the provider row and the "Request context providers" contract (context interface, read-only rationale, resolver ordering, custom-host providers resolver).
  • website/docs/{en,zh}/guide/authoring/mcp.mdx: new "Request context providers" section; website/docs/{en,zh}/guide/start/project-structure.mdx: provider row.
  • examples/worktree-proximity/README.md.
  • Changeset: .changeset/459-provider-request-context.md (agent-bundle patch, @agent-bundle/runtime patch).

Gates run locally: pnpm build, pnpm typecheck, pnpm lint, pnpm test:unit (3343 passed), pnpm test:route-unit, pnpm test:projection (171 passed), pnpm docs:site:build (language parity OK), pnpm --filter @agent-bundle-example/worktree-proximity check, and the integration files cli-routes-build, provider-typegen, worktree-proximity-journeys.

Self-review

…ices to context providers (#459)

Widen `AgentProviderContext` with `host`, `session`, `workspace`, `lineage`
(live tree included), `plugin`, and read-only `state` (`lifetime`, `read`) /
`notices` (`inbox`) views, exactly as the route observes them on
`await agent()`. Providers now run as the request's own resolver:
`runAgentRequest` accepts `providers` as an `AgentProviderResolver`
`(request: AgentProviderRequest) => values` beside the plain record and runs it
after the identity axes are frozen and the notice lease is open, before the
operation, outside the request's async context (`agent()`/`useAgent()` inside a
factory throw `outside-invocation`). Every generated request scope (Flight
worker, rendered CLI/script worker, plain routed CLI) and the `agent-bundle/test`
harness emit/mount that resolver instead of executing providers ahead of the
request.

worktree-proximity: `agent-topology` returns an available snapshot built from
`context.lineage` (tree) and `context.state.read()`; the coordinator `status`
tool reads `providers.agentTopology`.
@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9dfb7ee

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@agent-bundle/runtime Patch
agent-bundle Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T19:41:55.927230Z 7106013 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@556
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@556
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@556

commit: abe3f11

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 71060133e9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/entry-conventions.md
Comment on lines +298 to +300
resolver: `runAgentRequest` freezes the identity axes, opens the notice lease
(so `notices.inbox()` is real), then runs the factories sequentially in
deterministic key order, and only then runs the route. That ordering — state

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the remaining provider-ordering reference

This new ordering contradicts docs/framework-mode.md:150-152, which still tells users that providers run “before the request scope opens.” That guidance is now operationally wrong: custom hosts following it cannot supply the newly promised frozen identity axes or mounted state/notice handles. Update that reference to describe resolution after the notice lease opens and before the route runs.

AGENTS.md reference: AGENTS.md:L78-L81

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in abe3f11: docs/framework-mode.md now states that providers resolve after runAgentRequest freezes the identity axes and opens the notice lease, before the route, and lists the fields the factory context carries; it points at entry-conventions.md for the full contract. A search for any other "before the request scope" wording across docs/, website/docs/{en,zh}, and the example READMEs found no remaining instance.

@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

Closing as a duplicate of #552, which was opened first, carries the broader provider view (plugin, notices.published()) and is armed for auto-merge. The docs/framework-mode.md ordering fix from this PR will be carried in a follow-up.

@ScriptedAlchemy
ScriptedAlchemy deleted the feat/459-provider-context branch September 4, 2026 20:02
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
… MCP negotiation test; provider-view cross-process coverage (integration) (#554)

* fix(scripts): choose the TypeScript transform flag Node actually supports (Node 26 drops --experimental-transform-types)

runScript spawned every plain .ts script under
node --experimental-transform-types. Node 26 removed the flag
(nodejs/node#61803) and rejects it as a bad option (exit code 9), so the
Verify (Node 26) leg failed on every main push.

typeScriptTransformFlags (core/runtime.ts) decides from
process.allowedNodeEnvironmentFlags: the transform flag where the binary
accepts it (Node 22, 24), nothing on Node 26, which strips types unflagged.
Unit-tested against the flag sets of each release line.

* chore: name #554 in the changeset

* fix(scripts): name --strip-types on Node 26 so an inherited NODE_OPTIONS=--no-strip-types cannot switch TypeScript loading off

Codex review on #554: with no command-line flag the child inherited the
environment's --no-strip-types and failed on every typed .ts source. The
helper now picks the first flag the binary accepts, strongest first:
--experimental-transform-types (22, 24), then --strip-types (26). Covered by
a script-dispatch test that sets the version-appropriate switch in
NODE_OPTIONS and expects the source run to succeed regardless.

* test(routes): prove the provider request view across built surfaces; fix framework-mode.md ordering (from #556)

* fix(install): apply the operator .env layer before plugin modules evaluate and below manifest env defaults (#469)

Two findings from the #538 self-review.

Precedence: a host merges the stdio server's manifest `env` block into the
child environment, so the shell could not tell a manifest default from a host
export and reserved both — manifest env beat the file, contrary to the
documented `manifest < .env < .env.local < process.env`. The emitted stdio
entry now embeds the server's normalized `env` block as build-time literals
and `applyOperatorEnv` takes it as `manifestEnv`: a present variable is
reserved only when its value differs from the embedded default, so a
passed-through default yields to the file while a host or operator export is
kept. An operator export equal to the default is indistinguishable from the
pass-through and yields too; a default carrying a path token never equals its
expanded value and is always kept. Host manifests are unchanged.

Import timing: the layer was a statement after the consumer imports, and ESM
evaluates static imports first, so module-level `process.env` reads in hook
handlers and CLI route/provider modules never saw the file. A dynamic
`import()` after the statement does not help either — Rspack inlines a
single-chunk bundle into one scope and places the dynamic target ahead of
the static imports. The layer is now a generated virtual module
(`agent-bundle/launch-env-layer`) that every stdio entry, hook wrapper, and
artifact CLI bin imports first, with the server module, handler, routes,
providers, and state definition as static imports after it; the build marks
generated modules side-effectful so a consumer `"sideEffects": false` cannot
drop the bare import. The MCP shell's `loadEntry` becomes a static import
for the same reason, so the console guard now covers the factory call and
the running server rather than the module's top-level evaluation.

Tests build each shell through the real pipeline and run it under node with
a `process.env` read at module top level: manifest-only key takes the file,
host-exported key keeps the host value, absent key takes the file,
`AGENT_BUNDLE_ENV_FILE=none` restores the previous behaviour.

* chore: drop the tracked .superpowers scratch notes and ignore the folder

* fix(mcp): install the stdout guard in the stdio entry's first import so module-scope writes never reach the protocol stream (#469)

The env-precedence follow-up made the generated stdio entry import the
server module statically so the operator .env layer lands by import order —
but that put the module's top level ahead of the console guard that
`runGeneratedStdioMcpEntry` installs in the shell body. A `console.log` or
`process.stdout.write` at module scope in a consumer's server or tool module
reached stdout, which carries JSON-RPC framing, contradicting the documented
guarantee that redirection precedes the consumer module's evaluation.

The stdio shell now imports a generated prelude (`agent-bundle/stdio-prelude`)
as its first import: it calls `redirectConsoleToStderr` from
`agent-bundle/mcp-entry`, then applies the operator .env layer with the
server's manifest env defaults. Hook wrappers and the artifact CLI bin keep
the env-only layer (`agent-bundle/launch-env-layer`) — they legitimately
write stdout. The guard has one implementation: `redirectConsoleToStderr`
returns the guard already installed (recognised by `process.stdout.write`
still being its redirect) instead of stacking a second, which would capture
the redirect as the original and restore stdout to stderr; the lifecycle
adopts the prelude's guard and restores raw stdout from it before serving.

Tests: a built stdio entry whose server module writes `console.log('hello')`
and `process.stdout.write('raw\n')` at module scope, driven by a real stdio
client through initialize, tools/list, and tools/call, asserts both land on
stderr (fails on the previous code: stderr held only the factory-time line);
the entry-shell unit tests pin the prelude as the stdio entry's first import
and the env-only layer for hook wrappers and the CLI bin; the mcp-entry unit
test pins guard adoption and re-install after restore.

* fix(mcp): adopt the installed stdout guard regardless of write identity so a consumer wrapper cannot stack a second guard (#469)

Adoption by identity (`process.stdout.write === redirectedWrite`) broke the
moment a consumer module wrapped `process.stdout.write` at module scope: the
lifecycle's `redirectConsoleToStderr()` saw a foreign function, installed a
second guard with the wrapper recorded as the original, and restoring for
the protocol stream handed stdout to the wrapper — which still forwarded to
the first redirect, so every JSON-RPC frame left on stderr and the client
hung in initialize.

The rule is now: while a guard is installed, `redirectConsoleToStderr()`
returns it whatever `process.stdout.write` has become; `restoreProtocolStdout()`
restores the real original the guard owns, writes one stderr line if a module
replaced the write in the meantime (the replacement is discarded — stdout is
the protocol channel and wrapping it is unsupported), and clears the
installed guard so a later call installs anew.

Tests: the mcp-entry unit test wraps the redirect, adopts the same guard,
restores to the real stdout, and installs fresh afterwards (fails on
a677371 at the adoption step); the packed stdio test's server module now
also wraps `process.stdout.write` at module scope and the real client still
completes initialize, tools/list, and tools/call with the wrapper's output
and the warning on stderr (hangs to timeout on a677371).

* fix(mcp): make restoreProtocolStdout once-only so a stale or repeated restore cannot clobber a fresh guard (#469)

Two holders of the same guard could restore twice: after the first restore
and a fresh install, the stale restore overwrote the fresh redirect with the
old original while `installedGuard` still named the fresh guard, so adoption
returned a guard that was no longer installed. A plain double restore also
emitted the foreign-wrapper warning twice. The guard now records that it has
restored and returns immediately on later calls.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pass request identity, lineage, and read-only state/notices handles to context providers

1 participant